import {
  isSimple as isSimpleType,
  hasLowerCaseAlias,
  serializeAsType,
  serializeAsProperty,
  DEFAULT_NS_MAP,
  XSI_TYPE,
  parseName as parseNameNs
} from '../utils'

var XML_PREAMBLE = '<?xml version="1.0" encoding="UTF-8"?>\n'

var ESCAPE_ATTR_CHARS = /<|>|'|"|&|\n\r|\n/g
var ESCAPE_CHARS = /<|>|&/g

function filter(collection, matcher) {
  let result = []

  collection.forEach((val, key) => {
    if (matcher(val, key)) {
      result.push(val)
    }
  })

  return result
}

export function Namespaces(parent) {
  var prefixMap = {}
  var uriMap = {}
  var used = {}

  var wellknown = []
  var custom = []

  // API

  this.byUri = function(uri) {
    return uriMap[uri] || (parent && parent.byUri(uri))
  }

  this.add = function(ns, isWellknown) {
    uriMap[ns.uri] = ns

    if (isWellknown) {
      wellknown.push(ns)
    } else {
      custom.push(ns)
    }

    this.mapPrefix(ns.prefix, ns.uri)
  }

  this.uriByPrefix = function(prefix) {
    return prefixMap[prefix || 'xmlns']
  }

  this.mapPrefix = function(prefix, uri) {
    prefixMap[prefix || 'xmlns'] = uri
  }

  this.logUsed = function(ns) {
    var uri = ns.uri

    used[uri] = this.byUri(uri)
  }

  this.getUsed = function(ns) {
    function isUsed(ns) {
      return used[ns.uri]
    }

    var allNs = [].concat(wellknown, custom)

    return allNs.filter(isUsed)
  }
}

function lower(string) {
  return string.charAt(0).toLowerCase() + string.slice(1)
}

function nameToAlias(name, pkg) {
  if (hasLowerCaseAlias(pkg)) {
    return lower(name)
  } else {
    return name
  }
}

function inherits(ctor, superCtor) {
  ctor.super_ = superCtor
  ctor.prototype = Object.create(superCtor.prototype, {
    constructor: {
      value: ctor,
      enumerable: false,
      writable: true,
      configurable: true
    }
  })
}

function nsName(ns) {
  if (typeof ns === 'string' && ns.constructor === String) {
    return ns
  } else {
    return (ns.prefix ? ns.prefix + ':' : '') + ns.localName
  }
}

function getNsAttrs(namespaces) {
  return namespaces.getUsed().map(function(ns) {
    var name = 'xmlns' + (ns.prefix ? ':' + ns.prefix : '')
    return { name: name, value: ns.uri }
  })
}

function getElementNs(ns, descriptor) {
  if (descriptor.isGeneric) {
    return Object.assign({ localName: descriptor.ns.localName }, ns)
  } else {
    return Object.assign(
      { localName: nameToAlias(descriptor.ns.localName, descriptor.$pkg) },
      ns
    )
  }
}

function getPropertyNs(ns, descriptor) {
  return Object.assign({ localName: descriptor.ns.localName }, ns)
}

function getSerializableProperties(element) {
  var descriptor = element.$descriptor

  return filter(descriptor.properties, function(p) {
    var name = p.name

    if (p.isVirtual) {
      return false
    }

    // do not serialize defaults
    if (!element.hasOwnProperty(name)) {
      return false
    }

    var value = element[name]

    // do not serialize default equals
    if (value === p.default) {
      return false
    }

    // do not serialize null properties
    if (value === null) {
      return false
    }

    return p.isMany ? value.length : true
  })
}

var ESCAPE_ATTR_MAP = {
  '\n': '#10',
  '\n\r': '#10',
  '"': '#34',
  "'": '#39',
  '<': '#60',
  '>': '#62',
  '&': '#38'
}

var ESCAPE_MAP = {
  '<': 'lt',
  '>': 'gt',
  '&': 'amp'
}

function escape(str, charPattern, replaceMap) {
  // ensure we are handling strings here
  str = typeof str === 'string' && str.constructor === String ? str : '' + str

  return str.replace(charPattern, function(s) {
    return '&' + replaceMap[s] + ';'
  })
}

/**
 * Escape a string attribute to not contain any bad values (line breaks, '"', ...)
 *
 * @param {String} str the string to escape
 * @return {String} the escaped string
 */
function escapeAttr(str) {
  return escape(str, ESCAPE_ATTR_CHARS, ESCAPE_ATTR_MAP)
}

function escapeBody(str) {
  return escape(str, ESCAPE_CHARS, ESCAPE_MAP)
}

function filterAttributes(props) {
  return filter(props, function(p) {
    return p.isAttr
  })
}

function filterContained(props) {
  return filter(props, function(p) {
    return !p.isAttr
  })
}

function ReferenceSerializer(tagName) {
  this.tagName = tagName
}

ReferenceSerializer.prototype.build = function(element) {
  this.element = element
  return this
}

ReferenceSerializer.prototype.serializeTo = function(writer) {
  writer
    .appendIndent()
    .append(
      '<' + this.tagName + '>' + this.element.id + '</' + this.tagName + '>'
    )
    .appendNewLine()
}

function BodySerializer() {}

BodySerializer.prototype.serializeValue = BodySerializer.prototype.serializeTo = function(
  writer
) {
  writer.append(this.escape ? escapeBody(this.value) : this.value)
}

BodySerializer.prototype.build = function(prop, value) {
  this.value = value

  if (prop.type === 'String' && value.search(ESCAPE_CHARS) !== -1) {
    this.escape = true
  }

  return this
}

function ValueSerializer(tagName) {
  this.tagName = tagName
}

inherits(ValueSerializer, BodySerializer)

ValueSerializer.prototype.serializeTo = function(writer) {
  writer.appendIndent().append('<' + this.tagName + '>')

  this.serializeValue(writer)

  writer.append('</' + this.tagName + '>').appendNewLine()
}

function ElementSerializer(parent, propertyDescriptor) {
  this.body = []
  this.attrs = []

  this.parent = parent
  this.propertyDescriptor = propertyDescriptor
}

ElementSerializer.prototype.build = function(element) {
  this.element = element

  var elementDescriptor = element.$descriptor

  var propertyDescriptor = this.propertyDescriptor

  var otherAttrs, properties

  var isGeneric = elementDescriptor.isGeneric

  if (isGeneric) {
    otherAttrs = this.parseGeneric(element)
  } else {
    otherAttrs = this.parseNsAttributes(element)
  }

  if (propertyDescriptor) {
    this.ns = this.nsPropertyTagName(propertyDescriptor)
  } else {
    this.ns = this.nsTagName(elementDescriptor)
  }

  // compute tag name
  this.tagName = this.addTagName(this.ns)

  if (!isGeneric) {
    properties = getSerializableProperties(element)

    this.parseAttributes(filterAttributes(properties))
    this.parseContainments(filterContained(properties))
  }

  this.parseGenericAttributes(element, otherAttrs)

  return this
}

ElementSerializer.prototype.nsTagName = function(descriptor) {
  var effectiveNs = this.logNamespaceUsed(descriptor.ns)
  return getElementNs(effectiveNs, descriptor)
}

ElementSerializer.prototype.nsPropertyTagName = function(descriptor) {
  var effectiveNs = this.logNamespaceUsed(descriptor.ns)
  return getPropertyNs(effectiveNs, descriptor)
}

ElementSerializer.prototype.isLocalNs = function(ns) {
  return ns.uri === this.ns.uri
}

/**
 * Get the actual ns attribute name for the given element.
 *
 * @param {Object} element
 * @param {Boolean} [element.inherited=false]
 *
 * @return {Object} nsName
 */
ElementSerializer.prototype.nsAttributeName = function(element) {
  var ns

  if (typeof element === 'string' && element.constructor === String) {
    ns = parseNameNs(element)
  } else {
    ns = element.ns
  }

  // return just local name for inherited attributes
  if (element.inherited) {
    return { localName: ns.localName }
  }

  // parse + log effective ns
  var effectiveNs = this.logNamespaceUsed(ns)

  // LOG ACTUAL namespace use
  this.getNamespaces().logUsed(effectiveNs)

  // strip prefix if same namespace like parent
  if (this.isLocalNs(effectiveNs)) {
    return { localName: ns.localName }
  } else {
    return Object.assign({ localName: ns.localName }, effectiveNs)
  }
}

ElementSerializer.prototype.parseGeneric = function(element) {
  var self = this

  var body = this.body

  var attributes = []

  for (let key in element) {
    var nonNsAttr

    if (key === '$body') {
      body.push(new BodySerializer().build({ type: 'String' }, element[key]))
    } else if (key === '$children') {
      val.forEach(child => {
        body.push(new ElementSerializer(self).build(child))
      })
    } else if (key.indexOf('$') !== 0) {
      nonNsAttr = self.parseNsAttribute(element, key, element[key])

      if (nonNsAttr) {
        attributes.push({ name: key, value: element[key] })
      }
    }
  }

  return attributes
}

ElementSerializer.prototype.parseNsAttribute = function(element, name, value) {
  var model = element.$model

  var nameNs = parseNameNs(name)

  var ns

  // parse xmlns:foo="http://foo.bar"
  if (nameNs.prefix === 'xmlns') {
    ns = { prefix: nameNs.localName, uri: value }
  }

  // parse xmlns="http://foo.bar"
  if (!nameNs.prefix && nameNs.localName === 'xmlns') {
    ns = { uri: value }
  }

  if (!ns) {
    return {
      name: name,
      value: value
    }
  }

  if (model && model.getPackage(value)) {
    // register well known namespace
    this.logNamespace(ns, true, true)
  } else {
    // log custom namespace directly as used
    var actualNs = this.logNamespaceUsed(ns, true)

    this.getNamespaces().logUsed(actualNs)
  }
}

/**
 * Parse namespaces and return a list of left over generic attributes
 *
 * @param  {Object} element
 * @return {Array<Object>}
 */
ElementSerializer.prototype.parseNsAttributes = function(element, attrs) {
  var self = this

  var genericAttrs = element.$attrs

  var attributes = []

  // parse namespace attributes first
  // and log them. push non namespace attributes to a list
  // and process them later

  for (let key in genericAttrs) {
    var nonNsAttr = self.parseNsAttribute(element, key, genericAttrs[key])
    if (nonNsAttr) {
      attributes.push(nonNsAttr)
    }
  }

  return attributes
}

ElementSerializer.prototype.parseGenericAttributes = function(
  element,
  attributes
) {
  var self = this

  attributes.forEach(attr => {
    // do not serialize xsi:type attribute
    // it is set manually based on the actual implementation type
    if (attr.name === XSI_TYPE) {
      return
    }

    try {
      self.addAttribute(self.nsAttributeName(attr.name), attr.value)
    } catch (e) {
      console.warn(
        'missing namespace information for ',
        attr.name,
        '=',
        attr.value,
        'on',
        element,
        e
      )
    }
  })
}

ElementSerializer.prototype.parseContainments = function(properties) {
  var self = this

  var body = this.body

  var element = this.element

  properties.forEach(p => {
    var value = element.get(p.name)

    var isReference = p.isReference

    var isMany = p.isMany

    if (!isMany) {
      value = [value]
    }

    if (p.isBody) {
      body.push(new BodySerializer().build(p, value[0]))
    } else if (isSimpleType(p.type)) {
      value.forEach(v => {
        body.push(
          new ValueSerializer(self.addTagName(self.nsPropertyTagName(p))).build(
            p,
            v
          )
        )
      })
    } else if (isReference) {
      value.forEach(v => {
        body.push(
          new ReferenceSerializer(
            self.addTagName(self.nsPropertyTagName(p))
          ).build(v)
        )
      })
    } else {
      // allow serialization via type
      // rather than element name
      var asType = serializeAsType(p)

      var asProperty = serializeAsProperty(p)

      value.forEach(v => {
        var serializer

        if (asType) {
          serializer = new TypeSerializer(self, p)
        } else if (asProperty) {
          serializer = new ElementSerializer(self, p)
        } else {
          serializer = new ElementSerializer(self)
        }

        body.push(serializer.build(v))
      })
    }
  })
}

ElementSerializer.prototype.getNamespaces = function(local) {
  var namespaces = this.namespaces

  var parent = this.parent

  var parentNamespaces

  if (!namespaces) {
    parentNamespaces = parent && parent.getNamespaces()

    if (local || !parentNamespaces) {
      this.namespaces = namespaces = new Namespaces(parentNamespaces)
    } else {
      namespaces = parentNamespaces
    }
  }

  return namespaces
}

ElementSerializer.prototype.logNamespace = function(ns, wellknown, local) {
  var namespaces = this.getNamespaces(local)

  var nsUri = ns.uri

  var nsPrefix = ns.prefix

  var existing = namespaces.byUri(nsUri)

  if (!existing) {
    namespaces.add(ns, wellknown)
  }

  namespaces.mapPrefix(nsPrefix, nsUri)

  return ns
}

ElementSerializer.prototype.logNamespaceUsed = function(ns, local) {
  var element = this.element

  var model = element.$model

  var namespaces = this.getNamespaces(local)

  // ns may be
  //
  //   * prefix only
  //   * prefix:uri
  //   * localName only

  var prefix = ns.prefix

  var uri = ns.uri

  var newPrefix

  var idx

  var wellknownUri

  // handle anonymous namespaces (elementForm=unqualified), cf. #23
  if (!prefix && !uri) {
    return { localName: ns.localName }
  }

  wellknownUri =
    DEFAULT_NS_MAP[prefix] || (model && (model.getPackage(prefix) || {}).uri)

  uri = uri || wellknownUri || namespaces.uriByPrefix(prefix)

  if (!uri) {
    throw new Error('no namespace uri given for prefix <' + prefix + '>')
  }

  ns = namespaces.byUri(uri)

  if (!ns) {
    newPrefix = prefix
    idx = 1

    // find a prefix that is not mapped yet
    while (namespaces.uriByPrefix(newPrefix)) {
      newPrefix = prefix + '_' + idx++
    }

    ns = this.logNamespace(
      { prefix: newPrefix, uri: uri },
      wellknownUri === uri
    )
  }

  if (prefix) {
    namespaces.mapPrefix(prefix, uri)
  }

  return ns
}

ElementSerializer.prototype.parseAttributes = function(properties) {
  var self = this

  var element = this.element

  properties.forEach(p => {
    var value = element.get(p.name)

    if (p.isReference) {
      if (!p.isMany) {
        value = value.id
      } else {
        var values = []
        forEach(value, function(v) {
          values.push(v.id)
        })
        // IDREFS is a whitespace-separated list of references.
        value = values.join(' ')
      }
    }

    self.addAttribute(self.nsAttributeName(p), value)
  })
}

ElementSerializer.prototype.addTagName = function(nsTagName) {
  var actualNs = this.logNamespaceUsed(nsTagName)

  this.getNamespaces().logUsed(actualNs)

  return nsName(nsTagName)
}

ElementSerializer.prototype.addAttribute = function(name, value) {
  var attrs = this.attrs

  if (typeof value === 'string' && value.constructor === String) {
    value = escapeAttr(value)
  }

  attrs.push({ name: name, value: value })
}

ElementSerializer.prototype.serializeAttributes = function(writer) {
  var attrs = this.attrs

  var namespaces = this.namespaces

  if (namespaces) {
    attrs = getNsAttrs(namespaces).concat(attrs)
  }

  attrs.forEach(a => {
    writer
      .append(' ')
      .append(nsName(a.name))
      .append('="')
      .append(a.value)
      .append('"')
  })
}

ElementSerializer.prototype.serializeTo = function(writer) {
  var firstBody = this.body[0]

  var indent = firstBody && firstBody.constructor !== BodySerializer

  writer.appendIndent().append('<' + this.tagName)

  this.serializeAttributes(writer)

  writer.append(firstBody ? '>' : ' />')

  if (firstBody) {
    if (indent) {
      writer.appendNewLine().indent()
    }

    this.body.forEach(b => {
      b.serializeTo(writer)
    })

    if (indent) {
      writer.unindent().appendIndent()
    }

    writer.append('</' + this.tagName + '>')
  }

  writer.appendNewLine()
}

/**
 * A serializer for types that handles serialization of data types
 */
function TypeSerializer(parent, propertyDescriptor) {
  ElementSerializer.call(this, parent, propertyDescriptor)
}

inherits(TypeSerializer, ElementSerializer)

TypeSerializer.prototype.parseNsAttributes = function(element) {
  // extracted attributes
  var attributes = ElementSerializer.prototype.parseNsAttributes.call(
    this,
    element
  )

  var descriptor = element.$descriptor

  // only serialize xsi:type if necessary
  if (descriptor.name === this.propertyDescriptor.type) {
    return attributes
  }

  var typeNs = (this.typeNs = this.nsTagName(descriptor))
  this.getNamespaces().logUsed(this.typeNs)

  // add xsi:type attribute to represent the elements
  // actual type

  var pkg = element.$model.getPackage(typeNs.uri)

  var typePrefix = (pkg.xml && pkg.xml.typePrefix) || ''

  this.addAttribute(
    this.nsAttributeName(XSI_TYPE),
    (typeNs.prefix ? typeNs.prefix + ':' : '') +
      typePrefix +
      descriptor.ns.localName
  )

  return attributes
}

TypeSerializer.prototype.isLocalNs = function(ns) {
  return ns.uri === (this.typeNs || this.ns).uri
}

function SavingWriter() {
  this.value = ''

  this.write = function(str) {
    this.value += str
  }
}

function FormatingWriter(out, format) {
  var indent = ['']

  this.append = function(str) {
    out.write(str)

    return this
  }

  this.appendNewLine = function() {
    if (format) {
      out.write('\n')
    }

    return this
  }

  this.appendIndent = function() {
    if (format) {
      out.write(indent.join('  '))
    }

    return this
  }

  this.indent = function() {
    indent.push('')
    return this
  }

  this.unindent = function() {
    indent.pop()
    return this
  }
}

/**
 * A writer for meta-model backed document trees
 *
 * @param {Object} options output options to pass into the writer
 */
export function Writer(options) {
  options = Object.assign({ format: false, preamble: true }, options || {})

  function toXML(tree, writer) {
    var internalWriter = writer || new SavingWriter()
    var formatingWriter = new FormatingWriter(internalWriter, options.format)

    if (options.preamble) {
      formatingWriter.append(XML_PREAMBLE)
    }

    new ElementSerializer().build(tree).serializeTo(formatingWriter)

    if (!writer) {
      return internalWriter.value
    }
  }

  return {
    toXML: toXML
  }
}
